iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability系列 第 26 篇

Day 17(下)|好的 SLI 與 FastAPI SLO:量得到不代表該量

  • 分享至 

  • xImage
  •  

GitHub:darkstar1227/learning-sre-for-ai-era

結論先說:規格寫得再漂亮,沒有查詢跟反例驗證過,都只是紙上的承諾;這篇把(上)定義好的三個候選 SLI 轉成真正能跑的查詢,再用 DIY 步驟證明規格跟程式碼行為對得上,最後整理常見失敗與交給值班者的一頁說明。

承接上文:(上)從使用者句子倒推出 availability、latency、dependency 三個候選 SLI,把「成功」寫成可被反駁的 good_when/eligible_when 規格,並在 FastAPI middleware 與 handler 之間,用低基數的 RequestOutcome 事件把技術分類從 handler 傳給 middleware 記錄。(下)接著把這些事件轉成 PromQL 查詢,並動手驗證這一切真的如規格所寫。

⑥ 把事件轉成三個候選 SLI

現在回到一開始的三個使用者問題。

availability SLI

最小定義可以是:所有 eligible /ask 請求中,technical status 為 success 的比例。

sum(rate(answer_requests_total{
  route="/ask",
  technical_status="success"
}[5m]))
/
sum(rate(answer_requests_total{route="/ask"}[5m]))

這是短時間觀測式,不是 28 天 SLO 的唯一查詢。

短窗適合看趨勢。

正式 SLO 要明確指定長窗、資料保留、重設 counter 時的查詢行為,以及是否排除已定義的非使用者流量。

若有 safely_refused,你可以選擇另設 task_status metric,再由產品契約決定拒答是否算 availability good。

不要為了讓比率好看,默默把拒答全部納入成功。

latency SLI

傳統的「P95 < 3 秒」很常用,但它不是唯一寫法。

另一種 user-journey 定義是:成功請求中,有多少比例在三秒內完成。

sum(rate(answer_request_duration_seconds_bucket{
  route="/ask",
  le="3"
}[5m]))
/
sum(rate(answer_request_duration_seconds_count{
  route="/ask"
}[5m]))

兩種寫法回答的問題不同。

前者給你分布邊界。

後者直接給「在門檻內完成的比例」。

不要在同一個 SLO 文件裡交替使用,卻沒說明。

若使用 streaming,還要做一個 TTFT SLI。

端到端完成時間與第一個 token 的等待時間,都會影響體驗。

今天的 FastAPI 範例沒有實作 streaming。

Day 18 會補上 LLM 特有的時間分段。

dependency SLI

dependency SLI 多半不是對外承諾。

它像健康檢查燈,告訴你 availability 變壞時先往哪裡看。

sum(rate(answer_dependency_calls_total{
  dependency="llm_provider",
  outcome=~"timeout|error"
}[5m]))
/
sum(rate(answer_dependency_calls_total{
  dependency="llm_provider"
}[5m]))

若 fallback 成功,對 user-facing availability 的影響可能是零。

對成本、品質或延遲的影響卻可能很大。

用一組假設數字感受一下這個落差有多大。假設主要 provider 平均每次呼叫 0.02 美元、回應時間 800ms;fallback provider(例如換一個較小的模型)每次呼叫 0.06 美元、回應時間 2.1 秒。一天 10 萬次請求裡,若有 5% 觸發 fallback,這 5,000 次請求對 availability SLI 的貢獻是滿分(使用者都拿到答案),但當天的 API 成本多花了約 200 美元,而且這 5,000 位使用者的實際等待時間都超過 latency SLI 的門檻。如果 dependency SLI 只回答「有沒有壞」、不區分「直接成功」與「fallback 後成功」,這筆額外成本與體驗落差會完全消失在一片綠燈裡,直到月底帳單或使用者評價異常才被發現。

所以在 dependency metric 裡保留 fallback_success,而不是把它消失成普通 success。

PromQL 的 rate() 不是「這五分鐘的比率」,這件事容易被誤解

本文三個候選 SLI 的查詢都用了 rate(...[5m]),這個函式常被直覺理解成「過去五分鐘的平均速率」,這個理解大方向沒錯,但兩個細節值得說清楚,否則容易誤讀 dashboard 上的數字。

第一,counter 重設。Prometheus 的 Counter 型別只會遞增,但服務重啟、container 被 Day 3 那種方式強制 kill 重建,都會讓底層數值歸零。rate() 內建了偵測重設的邏輯——它會發現數值不減反增的異常下降是一次重設,並在計算時自動修正,不會讓你看到一條瞬間跌到負值的曲線。但這也代表,服務重啟前後那個時間點的計算結果,精確度會比平常低,這通常不是問題,除非你剛好在那個窗口做嚴格的 SLO 稽核。

第二,[5m] 這個視窗跟你查詢的頻率沒有直接關係。就算你每 15 秒查一次,每次查到的都是「往前推 5 分鐘」這個滑動視窗算出來的速率,不是「上次查詢到這次查詢之間」發生的事。這個特性讓短窗查詢的曲線天生比較平滑(因為每個點都跟前一個點共享了 4 分 45 秒的資料),但也代表視窗選得太長時,一次短暫的尖峰會被稀釋到看不出來——這正是①一開始提到「平均值掩蓋尾部現象」的同一個道理,只是這次發生在時間軸而不是分布上。真正需要精準抓住短暫異常時,查詢視窗要跟著調短,例如把 [5m] 換成 [1m],用犧牲曲線平滑度換取反應速度。

一個數字壞掉時的初步查法

現象 先看什麼 不該立刻推論
availability 下降、dependency timeout 上升 provider trace、DNS、egress、timeout 設定 「模型一定壞了」
latency 下降、availability 穩定 queue、DB、retrieval、provider latency 分布 「CPU 一定不足」
dependency error 上升、availability 未變 fallback ratio、token 成本、quality sample 「使用者沒有受影響」
HTTP 2xx 穩定、quality sample 下降 prompt、retrieval index、evaluator、release diff 「SLO 都綠所以沒事」

SLI 只負責把人帶到正確問題附近。

它把「這裡壞了嗎」的猜測,換成「這裡的數字說了什麼」的證據。

證據仍需要人去解讀。

它不能代替 investigation。

好的 SLI 縮短的是「找到問題方向」的時間,不是「解決問題」的時間。

兩者常被混為一談,值班壓力大的時候尤其容易。

為什麼是 rolling 28 天,而不是自然月或一週

上面的 SLO spec 選了 rolling-28d,這不是隨手挑的數字。Google SRE Workbook 建議用「滾動視窗」而非日曆月,原因是自然月長度不一致(28 到 31 天),拿 2 月跟 8 月的 SLO 直接比較會混進視窗長度本身造成的誤差。28 天則正好是 4 個完整的 7 天週期,能自然對齊「工作日 vs 週末」這種週期性流量模式,不會因為視窗邊界剛好切在週五半夜,讓某一天的流量權重被稀釋或放大。

視窗長度本身也是取捨:窗越短,越能快速反映近期變化,但雜訊也越大;窗越長,數字越穩定,卻要等到視窗滾動完才會完全反映在報表上。實務上通常兩種都留——短窗(1 小時、5 分鐘)偵測正在發生的異常,長窗(28 天)衡量是否兌現長期承諾。error budget 與依視窗長度分級的告警策略,會在 Day 20 進一步展開;Day 17 先把這三個 SLI 的分子分母定義穩固,才有東西可以拿去算 budget。

三個候選 SLI 之間的關係,不是三選一

一個常見誤會是把 availability、latency、dependency 想成互斥的選項,好像最後要挑一個「最重要」的當作唯一 SLO。實際上它們回答的是三個不同的使用者期待,通常會同時存在,只是各自的閾值與告警策略不同。

availability SLI  ──旨在回答──>  這次請求有沒有完成?
latency SLI        ──旨在回答──>  完成得夠快嗎?
dependency SLI      ──旨在回答──>  下游拖累我了嗎?(診斷用)

一個請求可以同時:
  availability = good(技術上完成了)
  latency      = bad(超過 3 秒門檻)
  dependency   = 顯示 provider 慢,佐證上面兩者的因果關係

三個指標一起看,才能回答「使用者體驗變差,是完全失敗、還是變慢、還是下游拖累」這種真實值班時最常見的問題。只留一個,等於主動丟掉診斷用的上下文。

⑦ 今日 DIY:先在本機替 SLI 製造反例

這一節給讀者自行操作的步驟。

本文沒有建立 DIY 專案、安裝套件、啟動 FastAPI 或執行任何命令。

請在你的獨立 Day 17 DIY 環境中完成,避免把實驗 middleware 直接帶進未知的 production service。

這個 DIY 到底在驗證什麼

在動手之前,先講清楚這七個步驟合起來要證明哪三件事——這樣即使不實際跑一次,也能看懂它在驗證前面章節的哪個論點。

第一,它驗證「分類邏輯可以獨立於 web framework 測試」(呼應 ③「可被反駁的規格」)。classify_status() 是一個純函式,不碰 HTTP、不碰 provider,任何人都能拿一組輸入手算出預期輸出,再跟程式實際跑出來的結果對照。這正是「規格要能被反駁」在程式碼層級的落地——如果分類邏輯藏在 handler 裡跟 I/O 混在一起,你沒辦法不架起整個服務就驗證它對不對。

第二,它驗證「middleware 記錄的東西跟 handler 分類的東西是同一份資料」(呼應 ⑤「handler 負責提供業務結果」)。Step 3 到 Step 5 刻意讓同一個請求同時留下三種紀錄——HTTP response、application log、Prometheus counter——目的是確認這三個地方講的是同一個故事。如果三者對不上(例如 log 說 timeout,metric 卻沒有對應的 counter 增加),代表 request.state 的傳遞路徑有漏洞,這種漏洞在 production 通常不會馬上炸開,而是安靜地讓 SLO 的分子分母失真。

第三,它驗證「label 基數真的維持低基數,不是紙上談兵」(呼應 ④「事件模型」)。Step 6 故意送出多筆帶不同內容的請求,檢查 metrics endpoint 有沒有跟著長出新的 label 組合。這是本文唯一一個「用真實流量測試 cardinality 假設」的步驟——前面章節講的都是原則,這一步是拿原則去撞一次現實。

跑完這三類驗證,你不會得到一個能上線的 production SLO,但會得到一組可重複執行的證據,證明「這份 SLI 規格跟這份程式碼的行為是一致的」——這正是 SLO 文件最常缺、卻最重要的東西:不是寫得好不好看,是有沒有辦法證明它跟系統實際行為對得上。

前置條件

你需要一個已能處理 /ask 的 FastAPI 應用程式。

你也需要已安裝並暴露 Prometheus client metric 的測試環境。

若還沒有 metrics endpoint,先沿用前幾天的 Lab 設定。

不要因為這篇文章而把 production token、provider key 或內部文件帶入本機範例。

Step 1:先寫 outcome 的純函式

先把分類邏輯拉出 web framework。

這能避免測試每一種失敗時都要真的呼叫外部 provider。

def classify_status(status_code: int, error_kind: str | None) -> str:
    if error_kind == "provider_timeout":
        return "dependency_timeout"
    if error_kind == "provider_error":
        return "dependency_error"
    if error_kind == "invalid_output":
        return "validation_error"
    if status_code >= 500:
        return "internal_error"
    return "success"

這段函式本身不是完整 production taxonomy。

它的用途是讓規則可以被例表反駁。

為什麼要先寫這個純函式,而不是直接寫 middleware? 因為分類邏輯是整個 SLI 系統裡最容易被悄悄改壞的一塊,而它偏偏也最適合寫成不需要真的發 HTTP request 就能測的東西。如果你直接在 middleware 或 handler 裡用 if/else 判斷 technical_status,之後每次想確認「provider timeout 有沒有被正確分類成 dependency_timeout」,都得真的架起服務、模擬一次逾時。抽成純函式後,這件事變成一行 assert classify_status(503, "provider_timeout") == "dependency_timeout"。跑起來時你會發現,這一步幾乎不會出錯——它太簡單了。真正容易出錯的是下一步:確認這個函式真的被 handler 正確呼叫、結果真的傳到了 middleware。

Step 2:寫出預期分類表

把以下案例改成你的產品語言後,寫成 unit test 或 fixture。

案例 HTTP error kind 預期 technical status availability good?
正常回答 200 無 success 是
upstream 逾時 503 provider_timeout dependency_timeout 否
上游回不合法 JSON 502 invalid_output validation_error 否
handler 未處理例外 500 無 internal_error 否
回 200 但答案缺事實 200 無 success 技術 SLI 是;quality 另算

最後一列最有價值。

若你的團隊覺得它該算 availability bad,也可以。

但那代表你決定把 technical 與 semantic reliability 混入同一個 SLI,後續 runbook 必須跟著處理更多分支。

Step 3:將 middleware 加到測試服務

把上一節的 middleware 改成符合你應用程式的 import 路徑。

先只對 /ask 計數。

避免 health check、/docs、metrics scrape 把分母灌大。

if request.url.path != "/ask":
    return await call_next(request)

這個 early return 對固定 endpoint Lab 很清楚。

若你的服務有多個需要承諾的 route,改用 allowlist 並在 SLO spec 列出來。

不要用 startswith("/");那等於每個新路由都被悄悄納入。

跑到這一步最常見的坑,是忘記 /metrics 本身也是一個會被 middleware 攔截到的 route。 Prometheus 拉取 /metrics 端點的行為,本身就是一次 HTTP request,如果 early return 的判斷條件寫錯(例如漏了某個路徑、或用了過寬的比對),metrics 端點的每一次 scrape 都會被算成一次 /ask 請求,讓 availability 分母被 scrape 頻率悄悄灌水——scrape 通常每 15 秒一次,永遠是 success,短時間內就能把手動測試的四種案例稀釋到數字上完全看不出來。這也是為什麼 Step 7 要求手算短窗比率再跟 query 結果核對:如果兩者對不上,且差距接近某個固定週期,先去檢查是不是漏掉了 scrape 流量。

Step 4:發出四種可預期請求

在測試雙替身或 feature flag 下,讓 /ask 依序回傳:

1. 正常 answer
2. provider timeout
3. invalid provider output
4. 未處理 internal error

每次請求後,檢查三個地方。

HTTP response:status 是否符合預期。
Application log:technical_status 與 latency_ms 是否存在。
Metrics endpoint:counter 是否只增加一個預期的 outcome。

如果同一個請求讓兩種 technical_status 都增加,先修 instrumentation。

錯誤的 telemetry 會製造錯誤的 error budget。

這一步實際跑起來,最容易發現的問題往往不是分類邏輯錯,而是「重複計數」。 常見的成因是 finally 區塊裡的 metric 呼叫被不小心放進了一個也會被 middleware 疊加機制重新觸發的路徑——例如同時掛了兩個功能重疊的 middleware(一個是這篇範例的 SLI middleware,另一個是團隊既有的 APM 或框架內建的請求日誌 middleware),兩者各自對同一個 request 增加了一次 counter。這在單元測試裡很難被抓到,因為單一 middleware 的測試本身完全正確;只有把整個 middleware stack 疊起來跑過一次真實請求,才會看到 counter 比預期多了一倍。這正是為什麼 Step 4 要求「檢查三個地方」而不是只看 HTTP response——重複計數不會反映在回應內容上,只會安靜地讓 SLO 分母膨脹。

Step 5:故意做一個 200 但不該被誤解的案例

讓測試替身回傳 syntactically valid、內容卻沒有引用正確政策的 answer。

此時預期是:

HTTP:200
technical_status:success
availability SLI:可列為 good
quality evaluator:應標為 failed 或 requires_human_review

這不是漏洞展示。

這正是 Day 7 與 Day 19 之間需要不同 SLI 的原因。

如果你把它算成 availability error,文件要明講,並用同一套規則重算歷史資料。

Step 6:檢查 label 數量

向 metrics endpoint 發出十個不同問題。

結果不應出現十個不同的 prompt label 或十個不同 request ID time series。

預期可見的 label 組合應接近這樣:

route="/ask"
technical_status="success|dependency_timeout|validation_error|internal_error"
release_channel="stable|canary"

若 series 數量隨使用者問題數線性增加,停止再送更多資料。

先移除高基數 label。

這一步是整個 DIY 裡最重要、卻最容易被跳過的驗證。 前六步都在確認「分類邏輯對不對」,這一步確認的是「代價可不可控」。很多人第一次跑這個實驗時,會直覺地把問題文字(例如 question 參數的內容)也塞進某個自訂 label 方便除錯,測試時看起來完全沒問題——畢竟只送了十個請求,series 數量微不足道。但這正是 ④ 節那個 50 萬條時間序列案例的起點:在小規模測試裡看不出 cardinality 問題,是因為小規模測試天生就不會觸發它。這一步的價值不是抓出本文範例程式碼的 bug(它本來就沒有把 question 當 label),而是建立一個習慣:任何新加的 label,上線前都先問一次「這個欄位的可能值有沒有上限」。

Step 7:用短窗 query 做 sanity check

在有測試 traffic 的短時間窗中,確認 success / total 與你手算的結果相同。

例如四個案例只有一個 success,候選 availability 應是 1 / 4。

它不是 production SLO 的報表。

它只是驗證分子、分母與程式分類一致。

預期觀測結果

完成後,你應能把單一測試請求對上三種資料。

request fixture
  → HTTP 503
  → application log technical_status=dependency_timeout
  → answer_requests_total{technical_status="dependency_timeout"} +1

正常請求則應該是:

request fixture
  → HTTP 200
  → application log technical_status=success
  → answer_requests_total{technical_status="success"} +1
  → duration histogram 多一筆 observation

若只有 HTTP 看得到,而 metrics 沒變,middleware 或 export path 有問題。

若 metrics 變了、log 卻無法關聯,之後排障會很痛苦;補上 request correlation,但仍不要把 request ID 變成 metric label。

驗收清單

  • [ ] SLO spec 寫明服務、route、時間窗、分子與分母。
  • [ ] 每個 excluded event 都有理由,而不是只為了改善百分比。
  • [ ] /ask 正常、timeout、validation failure、internal failure 都有可重跑的 fixture。
  • [ ] 每個 fixture 恰好產生一個 technical outcome。
  • [ ] log 中有 route、technical status、latency 與低敏感度版本資訊。
  • [ ] Prometheus metric 沒有 prompt、完整回答、email、request ID 或 trace ID label。
  • [ ] 短窗手算結果與 query 結果一致。
  • [ ] HTTP 200 但品質不佳的案例有另外的 evaluation 路徑。
  • [ ] 初始三秒門檻被標示為假設,尚未被宣稱為 production SLO。
  • [ ] SLI middleware 在整個 middleware stack 裡的註冊順序,跟 SLO spec 裡分母的定義一致。
  • [ ] 新增的每一個 label,都能寫出它的可能值數量上限,而不是「應該不會太多」。

⑧ 常見失敗:指標有了,決策還是錯

把 4xx 全排除

有些 4xx 的確是未授權或格式錯誤,未必屬於已承諾的 journey。

但 rate limit 誤傷、錯誤的 auth rollout、input validation regression,也可能讓正常使用者完全不能工作。

先拆出 4xx 類型,再依產品契約決定。

「4xx 不算」不是設計。

那只是省略。

拆開來看,4xx 底下藏著至少三種完全不同的使用者情境。

401 / 403   未授權存取
  → 通常不算,因為根本不是已承諾的 journey

422         input validation 失敗
  → 若是使用者輸入錯誤,通常不算
  → 若是前端自己送出了不合規格的 request(bug),要算

429         rate limit
  → 若是正常防濫用機制擋到真正濫用者,通常不算
  → 若是限流閾值設太低,誤傷了正常使用量,要算

同一個 HTTP status code,因為背後成因不同,該不該進分母的答案也不同。這也是為什麼「排除所有 4xx」聽起來像個省事的規則,實際上是把三種需要分開判斷的情境,用一句話含糊帶過。

用 HTTP status 推斷全部結果

一個 502 可以來自 output validation。

一個 503 可以來自 provider timeout。

兩者對 retry、fallback、事故溝通的處理不一定相同。

保留受控的 technical_status,但不要把原始 exception message 當 label。

只在 response 成功時 observe latency

這會讓慢而失敗的請求消失。

於是 P95 變好、使用者卻更不滿。

Latency SLI 要說清楚分母是所有 eligible request,還是只看 successful request。

兩種都能用。

不能混著用。

拿具體數字看這個陷阱有多容易發生。

一小時內 1,000 筆 /ask 請求:
  700 筆成功,平均 1.2 秒
  300 筆失敗,其中 250 筆是撐到 10 秒 timeout 才失敗

只對成功請求 observe latency:
  P95 latency 看起來是 2.1 秒,數字很漂亮

若對所有 eligible request observe latency(含失敗):
  P95 latency 其實超過 8 秒
  因為那 250 筆撐滿 timeout 的失敗請求也該算進分布

兩種算法都有正當用途——前者回答「成功的請求快不快」,後者回答「使用者平均要等多久才知道結果」。危險的不是選哪一種,是團隊沒有意識到自己選了哪一種,把「只看成功請求」算出來的漂亮數字,拿去回答「使用者平均要等多久」這個它從來沒有回答過的問題。

把 measurement 放在外部 client 後面

若只量 provider SDK 呼叫,會漏掉 request parsing、queue、retrieval、response serialization。

那是一個 dependency latency 指標,不是 user-facing request latency SLI。

兩個都值得有。

名字要誠實。

先訂 99.99%,再找資料配合

目標高不等於可靠。

若過去沒有 baseline、沒有可接受的維護成本,也沒有 error budget policy,四個九只是一張壓力海報。

先量一段明確樣本期。

再把候選目標、例外情況與發布決策寫出來。

一個常見的參照點是:如果系統過去 28 天實際跑出來的 availability 是 99.2%,直接把 SLO 目標訂成 99.99%,代表團隊承諾的失敗預算,比系統過去實際表現嚴格了將近 80 倍。

99.2%   → 每 28 天允許約 5.4 小時的 bad 時間
99.99%  → 每 28 天只允許約 4 分鐘的 bad 時間

四分鐘的預算,代表任何一次 5 分鐘以上的事故就會直接把整個月的預算燒光。如果沒有人真的打算為了這個數字投入相應的工程資源(多活架構、更嚴格的變更審查、更快的自動 rollback),這個目標只會不斷被違反,久而久之變成一個沒有人再認真看待的數字——這正是「先訂目標再找資料配合」最終的下場:不是系統變可靠了,是團隊對 SLO 這個機制本身失去信任。

把 histogram bucket 當成自然常數

bucket 決定你能看見哪些門檻。

如果產品承諾三秒,卻只有 1 和 5 秒 bucket,你無法精準算出三秒內比例。

bucket 應靠近決策門檻。

這也是為何本文範例刻意包含 3。

背後的原理:Prometheus 的 histogram 每個 bucket 都是「小於等於某門檻的累積次數」,le 就是 less than or equal。

histogram_quantile() 在算 P95 這類分位數時,只知道目標分位數落在哪一個 bucket 區間,接著假設該區間內的樣本均勻分布,用線性內插算出估計值。

如果 bucket 只有 1 秒與 5 秒兩檔,而 P95 剛好落在這個區間,Prometheus 只能在 1 到 5 秒之間內插猜測。

真實的延遲分布幾乎不可能均勻。

真正的 P95 可能落在 1.2 秒,也可能落在 4.8 秒,兩者對使用者體驗天差地別,但內插出來的估計值不會告訴你這個差異。

更麻煩的是,這個誤差不會反映在任何錯誤訊息裡——histogram_quantile() 永遠會回傳一個看起來合理的數字,不會因為 bucket 太稀疏而報錯或警告。dashboard 上的 P95 曲線一樣平滑、一樣能畫出漂亮的折線圖,唯一的問題是這條線背後的精確度,遠比它視覺上呈現的樣子低。這也是為什麼設計 histogram 時,bucket 邊界最好直接對齊 SLO 文件裡寫死的門檻數字,而不是套用框架預設值再事後才發現對不上。

這也是為什麼本文的 LATENCY histogram 特地把 3 放進 buckets。

門檻本身要落在某一個 bucket 邊界上,才能直接讀出「小於等於 3 秒的比例」這個精確數字,而不是仰賴內插去猜。

讓 SLI 反映架構重構,而不是被架構重構甩在後面

還有一種失敗不是設計錯,是設計對了,但沒有跟著系統演化。

/ask 今天只呼叫一個 LLM provider。

半年後,團隊加了 retrieval 步驟、加了第二個 fallback provider、把 handler 拆成三個內部服務。

原本的 dependency="llm_provider" label 還在,但它已經不能代表「整條依賴鏈」。

半年前:
  /ask ──> llm_provider

半年後:
  /ask ──> retriever ──> vector_db
       ──> llm_provider(主要)
       ──> llm_provider_fallback(次要)

如果 dependency SLI 沒有跟著加上 vector_db 與 llm_provider_fallback 這兩個新的依賴類別,值班者遇到 retrieval 超時時,仍然只會看到 dependency="llm_provider" 顯示一切正常——因為新加的依賴根本沒有被納入計算。

指標不會自己跟著架構圖更新。

每次加新依賴、拆服務、換 provider,都該回頭檢查一次 SLI 規格是不是已經跟不上系統實際的樣子。

這正是為什麼 ③ 節堅持 SLO spec 要跟 event schema 放在一起審查——它們該是同一份 PR review 的一部分,而不是分屬兩個永遠對不上的文件。

六種失敗回頭對照一次「四個問題」

把本節六種常見失敗放回 ③ 節的四個問題檢查一次,會發現它們的共通點:都是在某一題上偷懶。

失敗模式                      主要違反哪一題
──────────────────────────────────────────────
把 4xx 全排除                  問題3(分子分母能不能穩定重算)
用 HTTP status 推斷全部結果     問題2(值班者知道先看哪裡嗎)
只在成功時 observe latency      問題3(分母定義是否一致)
measurement 放外部 client 後面  問題1(使用者真的會感覺到它嗎)
先訂 99.99% 再找資料配合        問題4(團隊願意因此暫停發布嗎)
histogram bucket 當自然常數     問題2(值班者能不能讀出精確數字)

六種失敗沒有一種是因為「不知道 SLI 的定義」,全部是知道定義、卻在某個環節省了一步。這也是為什麼 ③ 節的四個問題值得在每次修改 SLI 規格時重新問一遍——它不是入門知識,是每次都要重新過一次的檢查清單。

⑨ 交給值班者的一頁說明

SLO 文件不需要像法律合約一樣厚。

但值班者應該不必翻程式碼才能知道紅燈代表什麼。

可以先維護這種格式。

## /ask availability SLI

使用者承諾:已授權的 `/ask` 請求能完成技術工作流程。

Good:`technical_status="success"`。
Total:所有 `route="/ask"` 且未定義為排除項目的 completed request。
Window:rolling 28 days。
Initial objective:待 baseline 後決定;本文不宣稱任何 production target。

When red:
1. 看 dependency timeout/error ratio。
2. 用 trace 或 log 對照 request outcome。
3. 比對最近 release channel 與 fallback ratio。
4. 依 runbook 決定 rollback、限流、切換 fallback 或升級供應商事件。

這份文字比 dashboard 的小數點更重要。

因為半夜收到 alert 的人需要下一步,不需要一堂監控哲學課。

它該寫得像一份交接筆記,不是一份規格書。

這份文件也不必等到系統穩定後才寫。恰好相反,越是還在快速迭代、SLI 定義還可能修改的階段,越需要這一頁說明——它強迫團隊在改動 good_when/eligible_when 的同一個 PR 裡,順手更新「when red 該看哪裡」,讓規格與應變手冊永遠保持同步,而不是規格改了三次,runbook 還停在最初的版本。

把這份文件套進一次假想值班,看它撐不撐得住

寫完模板,最好的檢驗方式是拿一個具體情境跑一遍「when red」的四個步驟,確認每一步都真的有東西可以看,而不是寫了卻沒有對應的資料來源。

凌晨 02:14,pager 響了
  availability SLI 從 99.6% 掉到 97.1%(rolling 5m)

值班者打開這份一頁說明,照著 when red 走:

1. 看 dependency timeout/error ratio
   → answer_dependency_calls_total{outcome="timeout"} 同時間段飆升
     初步判斷:問題可能在下游,不是本服務程式碼

2. 用 trace 或 log 對照 request outcome
   → 抽 5 筆 dependency_timeout 的 request_id
   → trace 顯示每一筆都卡在 llm_provider 的呼叫上,
     耗時穩定超過設定的 timeout 門檻
   → log 裡沒有 internal_error,排除是自家 handler 的邏輯問題

3. 比對最近 release channel 與 fallback ratio
   → release_channel 沒有變化(沒有剛好在同時間發布新版本)
   → fallback_success 的比例也沒有明顯上升
     → 代表 fallback 機制本身可能也受到影響,或還沒被觸發

4. 依 runbook 決定 rollback、限流、切換 fallback或升級供應商事件
   → 排除是己方變更造成,判斷為 provider 端事故
   → 執行 runbook 裡「切換至 fallback provider」的既定程序
   → 同時開一張供應商事件工單,附上上述 trace 與 log 證據

這個走一遍的過程之所以重要,是因為它會馬上暴露文件裡「寫了但沒有資料支撐」的段落。如果第 2 步寫著「用 trace 對照」,但團隊根本沒有幫 /ask 接上 trace(例如 OTLP 還沒串到這條路徑),值班者半夜翻到這一步只會卡住,文件反而變成一份寫著做不到的事的清單。每次修改 SLI 規格,最好都順手跑一次這種假想演練,確認 runbook 上寫的每一步,此刻真的有東西可以看。

同一份模板也該對得上另一種常見情境——不是完全失敗,是變慢:latency SLI 從 98% 掉到 89%,但 availability 幾乎沒動。這時候第 1 步(dependency ratio)排除下游失聯,第 2 步的 trace 顯示是 process_time 本身拉長(不是排隊、不是可觀測性開銷),第 3 步發現 canary 剛好在 15 分鐘前擴大流量——三步合起來指向新版本讓 provider 回應變慢,而不是切換 fallback,runbook 走向是「先把 canary 流量調回去、觀察 latency 是否回穩」。

這個情境的走法跟前一個不一樣,是因為它示範了同一份「when red」清單,遇到「完全失敗」與「變慢但仍完成」這兩種不同的紅燈,會走向不同的結論。這正是 ⑥ 節強調「三個候選 SLI 不是三選一」的原因——只有同時保留 availability 與 latency 兩條線,值班者才分得出這次紅燈屬於哪一種。

⑩ 本文結論

SLI 不是資料庫欄位清單。

它是對使用者結果做出的可重算承諾。

寫這篇文章時反覆出現的一個模式是:每一次看起來只是「指標選錯了」的事故,往回追都會發現真正的問題出在更早的一步——不是量測工具不夠好,是沒有人先把「什麼叫成功」講清楚。GitHub 的資料一致性抉擇、Slack 的自動化代理指標、電商的 503 快速失敗,這些案例裡的監控系統技術上都運作正常,錯的是選錯了要問的問題。這也是 Day 17 花這麼多篇幅在①③兩節,而不是急著跳進程式碼的原因。

先定義 eligible event,再定義 good,再把每一種反例寫成可重跑的 fixture。

FastAPI middleware 可以替你捕捉 request 邊界。

它不能替你決定何謂成功,也不能替你判斷 LLM 回答有沒有說對。

先把 technical availability、latency 與 dependency health 分清楚,下一步才有空間把 AI 特有的品質與安全訊號接進來。

把今天走過的路收攏成一張圖,會更清楚每一節在整條鏈上的位置。

① 使用者句子
     │  「得到可用回覆」「不要等太久」「依賴故障不拖垮我」
     ▼
③ 可被反駁的規格
     │  good_when / eligible_when,能被 fixture 反駁
     ▼
② ④ 事件模型
     │  低基數 event,metric / log / trace 各司其職
     ▼
⑤ FastAPI middleware + handler
     │  middleware 量邊界,handler 給分類
     ▼
⑥ 三個候選 SLI
     │  availability / latency / dependency,各自可查詢
     ▼
⑦ DIY fixture
     │  反例先行,證明規格跟程式碼行為一致
     ▼
⑧ 常見失敗清單
     │  校對規格有沒有踩進已知陷阱
     ▼
⑨ 值班者的一頁說明
        紅燈亮起時,人類下一步要做什麼

這條鏈上,Google/GitHub/Slack 的三個事故(Tail at Scale 的尾端延遲、Oct 21 的可用性與一致性衝突、Jan 4 的代理指標誤導自動化決策)與 AWS Lambda、Google Borg、電商 503 三個案例,分別落在不同節點:有的告訴我我們為什麼不能只看平均值,有的告訴我們 eligible event 的定義多容易出錯,有的告訴我們代理指標一旦接上自動化決策會有多危險。它們共同的教訓其實只有一件事——量測系統本身的設計品質,決定了它在真正故障時,能不能把人帶到對的地方。這也是為什麼這篇文章的標題是「量得到不代表該量」:FastAPI、Prometheus、histogram、middleware,這些都只是把一件事「量出來」的工具,真正決定它有沒有用的,是①到④那幾個先問清楚的問題。

Day 18 預告

Day 17 把 availability、latency、dependency 組成貼近使用者體驗的 FastAPI SLI。

Day 18 要把同一套事件模型搬進 LLM application。

TTFT、queue delay、token throughput、retrieval failure 與 model failure 會出現在同一條 workflow 裡,但不該被壓成一個模糊的「AI latency」。

今天定義的 RequestOutcome、middleware 疊加順序、eligible/good 的判斷流程,到了 Day 18 不會被丟掉重來——它們是骨架,LLM 特有的訊號是要接進這個骨架的新肌肉,而不是另起爐灶的第二套系統。

延伸閱讀


這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.


上一篇
Day 17(上)|好的 SLI 與 FastAPI SLO:量得到不代表該量
下一篇
Day 18(上)|LLM Application SLI:把「慢」拆到能修的位置
系列文
Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability 共 44 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言